Fix use-after-free race condition in C extension for free-threaded CPython (3.13t+) - #1317
Fix use-after-free race condition in C extension for free-threaded CPython (3.13t+)#1317rodrigobnogueira wants to merge 19 commits into
Conversation
ff45477 to
0b04769
Compare
0af9e6a to
e1cbca0
Compare
This comment was marked as outdated.
This comment was marked as outdated.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1317 +/- ##
=======================================
Coverage 99.86% 99.86%
=======================================
Files 28 29 +1
Lines 3627 3666 +39
Branches 265 271 +6
=======================================
+ Hits 3622 3661 +39
Misses 3 3
Partials 2 2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
62dce51 to
8121e70
Compare
|
@rodrigobnogueira it's been 2 weeks so I'll see if we can get someone else in here to merge since I don't have that authorization to do so yet. for now I recommend staying updated with the main branch. |
Add per-object locking via Py_BEGIN_CRITICAL_SECTION at all public-facing entry points in the C extension to prevent use-after-free crashes when iterating and mutating a MultiDict concurrently. The root cause is _md_resize() reallocating md->keys without any synchronization, causing concurrent iterators to access freed memory. This fix wraps all public methods in Py_BEGIN_CRITICAL_SECTION(self), matching CPython's own dict locking model. On GIL builds the macros are no-ops (via pythoncapi_compat.h). Adds tests/test_free_threading.py with a concurrent stress test that previously crashed with SIGSEGV and now completes cleanly.
On non-free-threaded builds, Py_END_CRITICAL_SECTION() expands to just '}'. When preceded by a goto label like 'cs_done:', this creates a C23 extension that clang -Werror rejects. Adding an empty statement (;) after the label makes it valid C11.
In debug builds, Py_BEGIN_CRITICAL_SECTION can suspend during Python calls (e.g. __eq__ in md_calc_identity), allowing another thread to enter. The md_replace finder pattern uses hash=-1 markers internally, and ASSERT_CONSISTENT inside md_finder_cleanup catches this temporary inconsistency, calling abort(). Remove del operations from the writer loop since simple set+read is sufficient to exercise the core resize race fix.
b9ecbaf to
1d70349
Compare
- Use `any_multidict_class` fixture instead of manual `@pytest.mark.parametrize` - Use modern type annotations (`type[]` instead of `Type[]`, `|` instead of `Union`) - Add explanatory comments to empty except clauses (CodeQL feedback) - Use RST roles in changelog (`:class:`, `:c:func:`) for highlighted identifiers - Remove manual pure-python skip (fixture handles both variants) - Use `MutableMultiMapping` for internal type hints
1d70349 to
469dda9
Compare
|
While fixing the C extension race condition, our test suite (now running the Root cause: assert hash_ != -1 # line 543...and raises AssertionError because it observed the in-progress -1 sentinel from the other thread. The fix for the C extension in this PR uses Py_BEGIN_CRITICAL_SECTION, but the pure-Python implementation has no equivalent synchronization. Fixing it properly would require adding a threading.Lock to MultiDict. I've skipped the test for the pure-Python variant for now to unblock this PR. |
|
Error showed up in CI (traceback from Thread B): Two concurrent
|
|
See #1327 |
|
I'm unclear how much we should be concerned with such issues. The entire point of aio-libs is to provide asyncio libraries, so threading issues shouldn't be relevant. I'd rather not start introducing threading locks into our libraries.. |
@Dreamsorcerer You have to remember to that multidict is used in other libraries or projects besides our own. An example that I have is litestar however I'm with you on questioning weather or not thread locks should be considered acceptable or unacceptable however. |
Adding threading.Lock to the pure-Python fallback would add overhead for all users to solve a problem that is probably an edge case. Would it be acceptable to emit a RuntimeWarning at import time only when both conditions are true: running on free-threaded CPython (GIL disabled) AND the C extension is not available? |
We're talking about just iterating and modifying the same multidict, right? Can just mention that this shouldn't be done in a threaded situation. Maybe issue a warning. |
Added a RuntimeWarning in init.py that fires only when the pure-Python fallback is used on free-threaded CPython (GIL disabled + C extension unavailable). |
|
Are we still planning to move forward with these free-threaded PRs at all or will these be closed? |
Master adopted the PLC0415 lint rule (top-level imports) after this branch was created; updating the branch surfaced it on the in-function import in the writer helper.
|
pre-commit surfaced the new PLC0415 rule, fixed in 4079a3e. |
IMO this solves the issue, and the main cost is how many functions it has to touch: every entry point that reads md->keys needs the critical section, the same way CPython's dict does it. The sections are no-ops on regular builds, so only free-threaded builds pay. I think it deserves fixing: this is memory unsafety, not a logical race. A pure-Python user on 3.13t can segfault the process, where CPython's dict raises RuntimeError. The lighter alternative is documenting it as unsupported and keeping only the RuntimeWarning, but that leaves a crash reachable from Python code. |
Reference Py_BEGIN_CRITICAL_SECTION through the Sphinx c:macro role (resolved via intersphinx, same as the existing c:func usage in CHANGES.rst) and name the internal buffer md->keys as it appears in the C code.
|
This is the right approach — mirroring CPython 1. The cross-object paths lock only 2. A few of the crashes are reachable single-threaded, so they survive the critical sections. A critical section is a no-op on the default (GIL) build and doesn't guard same-thread reentrancy, so these need the function bodies fixed regardless of locking — they're borrowed-pointer-across-callback bugs, not pure data races:
The fix for these is in the bodies: re-fetch the entries base after any call that can mutate/resize (the |
|
Doing a free-threading pass over multidict I built a tree with this PR, #1327 and #1379 all merged, and one path this PR does not reach turned up. Flagging it here rather than opening an issue, since it belongs to the work you are already doing.
entry_t *entries = htkeys_entries(other->keys);
Py_ssize_t nentries = other->keys->nentries;
for (pos = 0; pos < nentries; pos++) {
entry_t *entry = entries + pos;The four Measured on 3.14.0rc1t with all three PRs applied — 8 threads running The last control is the one that places it: with matching Not reporting this as a separate finding — it is the race already documented, not a new one. Reproducer available if useful. |
md_update_from_ht() takes a raw entry_t pointer into the source multidict's table and walks it. The destination was locked by the calling entry point, but the source never was, so a concurrent insert could resize it and free the array mid-walk. On a free-threaded build that is a reliable segfault, reproduced 12/12 on 3.14.6t with eight threads extending while three mutate the source. It still crashes when both sides share is_ci, which rules out the borrow-across-Python path and places it on _md_resize. Critical sections are not reentrant, so the second lock cannot be taken inside md_update_from_ht(); the caller already holds the destination. The constructors, extend(), update() and merge() now take Py_BEGIN_CRITICAL_SECTION2 instead, the way CPython's dict_merge() does. _multidict_extend_source() is the one predicate deciding whether a second object is involved, and both the lock and the dispatch use it so they cannot disagree. Locking at the entry point also covers md_clone_from_ht(), which memcpy's the source table and is reached through the same argument.
The entry points resolve the argument to decide whether a second critical section is needed, and _multidict_extend() was then resolving it again to dispatch. Pass the resolved pointer down so the type checks run once, as they did before this branch. This also makes the guarantee stronger than "both sides use the same predicate": the lock and the dispatch now use the same pointer, so they cannot diverge even if the predicate changes later.
The previous commit dropped the hashtable.h paragraph explaining why the second object is locked at the entry point, so the branch carried the two-object locking change without the comment that justifies it. Put it back. Both copies of the rationale said the lock and the dispatch go through one shared predicate. Since the resolved pointer is passed down, the guarantee is stronger than that: the object locked and the object walked are the same pointer, not two reads that happen to agree. Reword to say so. Also hold the mutators until every extender has finished, so the source keeps being resized for the whole test rather than only until the first one returns. Still detects the bug 10/10 on the pre-fix build and passes 10/10 on this one.
ASSERT_CONSISTENT() walks the whole hash table, so evaluating it without the object's lock held is not a stale read but an unsynchronised walk of a buffer another thread can resize and free. Eighteen of them sat outside the section that guards the operation they follow, and two of those ran before the section was even opened. On a debug free-threaded build a plain getall() against a concurrent __setitem__ aborted every run on CHECK(entry->hash != -1), observing another thread's transient finder marker; the same shape also produced a segfault. Most of the strays duplicated a check the md_* helpers already perform under the lock, so they are removed rather than moved. getall() keeps one, moved inside its section, because md_get_all() has none of its own, and the two in views.h move above Py_END_CRITICAL_SECTION(). The one on multidict_copy() stays: it asserts a freshly built object that no other thread can reach yet. The reader and writer in test_race_condition_iterator_vs_mutation now also call getall/get/add/popone/setdefault, the entry points whose checks were unlocked. Nothing in the suite reached them before, which is why the debug job stayed green. The extended test aborts 8/8 on the previous build and passes 8/8 on this one. Also fixes the free-threaded fallback warning, which inferred a CPython-private attribute from the version number and would raise AttributeError on any other implementation reporting 3.13+, and reworded it: MULTIDICT_NO_EXTENSIONS is read with bool(), so setting it to "0" disables the extension while the old text claimed it was unavailable. The header comment claimed same-object re-entrancy was the only hazard of calling Python inside a critical section. A section is also suspended whenever the holding thread detaches, so a marked entry stays visible to other threads across the value comparisons in views.h; getall() can drop a value there on a release build. Documented as a known gap, since closing it needs the callbacks hoisted out of the marked window.
Description
This PR fixes a critical memory-safety race condition (Use-After-Free) that results in a process segmentation fault or abort (stack smashing) when iterating over and modifying a
MultiDictconcurrently in CPython free-threaded mode (3.13t+).Context
When running without the GIL, iterating over
md.keys()/md.items()or view objects could safely yield memory pointers pointing toMultiDictObject->keys. However, if the dictionary resized concurrently using_md_resize(), the keys table was reallocated and immediatelyfree()d, leaving concurrent iterators reading stale heap pointers. This explicitly leaked and corrupted process memory resulting in an ASAN/SEGV error on theREADinside the iterator buffers.Proposed Approach
To safely resolve this while adhering to Python 3.13 free-threaded lock mechanisms, we matched CPython's own
dictconcurrent locking pattern. This involves addingPy_BEGIN_CRITICAL_SECTION(self)/Py_END_CRITICAL_SECTION()at every public-facing entry point in the C extension. Thepythoncapi_compat.hshim already natively skips these macros on pre-3.13 monolithic GIL designs, making this 100% zero-overhead everywhere except 3.13t._multidict.c(__getitem__,__setitem__,__delitem__,add,getall,pop,copy, etc.),iter.h,cs_donefor safety unloads inviews.h.Verification
test_free_threading.py): Added a new threading test module that heavily threadsCIMultiDictadding and deleting inputs while natively reading iterations concurrently.finder->version != finder->md->versionsuccessfully, naturally abandoning the iteration and surfacing standardRuntimeError('MultiDict is changed during iteration')without throwing a C segmentation fault! This matches native single-threaded Py-behavior correctly.clang-formatand confirmed 100% test coverage matches the baseline branch overpython3.13t.